🎖️GitЯра🎖️
Commit 92efb53a5a1dddc263e4f1fa59f7e71c3d0e3fc4
Parents : 5a69ac1
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T02:11:34-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T12:11:34Z
fix(service): await persisted device before boot reconnect (#6617)
Changes
6 files changed, 375 insertions(+), 7 deletions(-)
Diff
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
index c7d60a812e..61a15b0e8b 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -58,6 +59,9 @@ class MeshPrefsImpl(private val dataStore: MeshDataStore, dispatchers: Coroutine
}
}
+ override suspend fun awaitDeviceAddress(): String? =
+ dataStore.data.first()[KEY_DEVICE_ADDRESS_PREF] ?: NO_DEVICE_SELECTED
+
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> = cachedFlow(storeForwardFlows, address) {
val key = intPreferencesKey(storeForwardKey(address))
dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0)
diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt
new file mode 100644
index 0000000000..3779e31ef0
--- /dev/null
+++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.prefs.mesh
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import okio.FileSystem
+import okio.Path
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MeshDataStore
+import org.meshtastic.core.prefs.di.asMeshDataStore
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.uuid.Uuid
+
+class MeshPrefsImplTest {
+ private lateinit var tmpDir: Path
+ private lateinit var dataStore: DataStore<Preferences>
+ private lateinit var testScope: TestScope
+ private lateinit var dispatchers: CoroutineDispatchers
+
+ @BeforeTest
+ fun setup() {
+ val testDispatcher = UnconfinedTestDispatcher()
+ testScope = TestScope(testDispatcher)
+ dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
+ tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "meshPrefsTest-${Uuid.random()}"
+ FileSystem.SYSTEM.createDirectories(tmpDir)
+ dataStore =
+ PreferenceDataStoreFactory.createWithPath(
+ scope = testScope,
+ produceFile = { tmpDir / "test.preferences_pb" },
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ testScope.cancel()
+ FileSystem.SYSTEM.deleteRecursively(tmpDir)
+ }
+
+ @Test
+ fun `await device address waits for persisted data instead of returning the flow default`() = testScope.runTest {
+ val persistedAddress = "xAA:BB:CC:DD:EE:FF"
+ dataStore.edit { preferences -> preferences[MeshPrefsImpl.KEY_DEVICE_ADDRESS_PREF] = persistedAddress }
+ val loadGate = CompletableDeferred<Unit>()
+ val delegate = dataStore.asMeshDataStore()
+ val delayedDataStore =
+ object : MeshDataStore by delegate {
+ override val data = delegate.data.onStart { loadGate.await() }
+ }
+ val prefs = MeshPrefsImpl(delayedDataStore, dispatchers)
+
+ assertEquals("n", prefs.deviceAddress.value)
+ val snapshot = async { prefs.awaitDeviceAddress() }
+ assertFalse(snapshot.isCompleted)
+
+ loadGate.complete(Unit)
+
+ assertEquals(persistedAddress, snapshot.await())
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index e776c5a2aa..0f50a68308 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -338,6 +338,9 @@ interface MeshPrefs {
fun setDeviceAddress(address: String?)
+ /** Persisted selected-device address; suspends for the first disk load instead of returning the flow's default. */
+ suspend fun awaitDeviceAddress(): String?
+
fun getStoreForwardLastRequest(address: String?): StateFlow<Int>
fun setStoreForwardLastRequest(address: String?, timestamp: Int)
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt
new file mode 100644
index 0000000000..d46488ef93
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/BootCompleteReceiverTest.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.service
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.Intent
+import android.os.Bundle
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshPrefs
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowBroadcastPendingResult
+import org.robolectric.util.ReflectionHelpers
+import org.robolectric.util.ReflectionHelpers.ClassParameter
+import java.io.IOException
+import kotlin.coroutines.CoroutineContext
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class BootCompleteReceiverTest {
+
+ @After
+ fun tearDown() {
+ stopKoin()
+ }
+
+ @Test
+ fun `persisted selected address starts service exactly once after load`() = runTest {
+ val persistedAddress = CompletableDeferred<String?>()
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ installDependencies(DeferredMeshPrefs { persistedAddress.await() }, dispatcher)
+ val application = testApplication()
+
+ val pendingResult = dispatchBootCompleted(BootCompleteReceiver(), application)
+ runCurrent()
+
+ assertFalse(pendingResult.future.isDone)
+ assertTrue(shadowOf(application).allStartedServices.isEmpty())
+
+ persistedAddress.complete("xAA:BB:CC:DD:EE:FF")
+ runCurrent()
+
+ assertTrue(pendingResult.future.isDone)
+ val startedServices = shadowOf(application).allStartedServices
+ assertEquals(1, startedServices.size)
+ assertEquals(MeshService::class.java.name, startedServices.single().component?.className)
+ }
+
+ @Test
+ fun `persisted no-device sentinel does not start service after load`() = runTest {
+ val persistedAddress = CompletableDeferred<String?>()
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ installDependencies(DeferredMeshPrefs { persistedAddress.await() }, dispatcher)
+ val application = testApplication()
+
+ val pendingResult = dispatchBootCompleted(BootCompleteReceiver(), application)
+ runCurrent()
+ persistedAddress.complete("n")
+ runCurrent()
+
+ assertTrue(pendingResult.future.isDone)
+ assertTrue(shadowOf(application).allStartedServices.isEmpty())
+ }
+
+ @Test
+ fun `pending result finishes when persistence load times out`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ installDependencies(DeferredMeshPrefs { awaitCancellation() }, dispatcher)
+ val application = testApplication()
+
+ val pendingResult = dispatchBootCompleted(BootCompleteReceiver(), application)
+ runCurrent()
+ advanceUntilIdle()
+
+ assertTrue(pendingResult.future.isDone)
+ assertTrue(shadowOf(application).allStartedServices.isEmpty())
+ }
+
+ @Test
+ fun `timeout includes time queued for IO dispatcher`() = runTest {
+ val ioDispatcher = StallingDispatcher()
+ val timeoutDispatcher = StandardTestDispatcher(testScheduler)
+ var persistenceReadStarted = false
+ val meshPrefs = DeferredMeshPrefs {
+ persistenceReadStarted = true
+ awaitCancellation()
+ }
+ installDependencies(meshPrefs, ioDispatcher, timeoutDispatcher)
+ val application = testApplication()
+
+ val pendingResult = dispatchBootCompleted(BootCompleteReceiver(), application)
+
+ assertEquals(1, ioDispatcher.queuedTaskCount)
+ assertFalse(persistenceReadStarted)
+ assertFalse(pendingResult.future.isDone)
+
+ advanceTimeBy(4_999)
+ runCurrent()
+ assertFalse(pendingResult.future.isDone)
+
+ advanceTimeBy(1)
+ runCurrent()
+
+ assertTrue(pendingResult.future.isDone)
+ assertFalse(persistenceReadStarted)
+ assertTrue(shadowOf(application).allStartedServices.isEmpty())
+ }
+
+ @Test
+ fun `pending result finishes when persistence load fails`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ installDependencies(DeferredMeshPrefs { throw IOException("read failed") }, dispatcher)
+ val application = testApplication()
+
+ val pendingResult = dispatchBootCompleted(BootCompleteReceiver(), application)
+ runCurrent()
+
+ assertTrue(pendingResult.future.isDone)
+ assertTrue(shadowOf(application).allStartedServices.isEmpty())
+ }
+
+ private fun installDependencies(
+ meshPrefs: MeshPrefs,
+ ioDispatcher: CoroutineDispatcher,
+ defaultDispatcher: CoroutineDispatcher = ioDispatcher,
+ ) {
+ startKoin {
+ modules(
+ module {
+ single { meshPrefs }
+ single {
+ CoroutineDispatchers(io = ioDispatcher, main = defaultDispatcher, default = defaultDispatcher)
+ }
+ },
+ )
+ }
+ }
+
+ private fun testApplication(): Application =
+ ApplicationProvider.getApplicationContext<Application>().also { shadowOf(it).clearStartedServices() }
+
+ private fun dispatchBootCompleted(
+ receiver: BootCompleteReceiver,
+ application: Application,
+ ): ShadowBroadcastPendingResult {
+ val pendingResult =
+ ReflectionHelpers.callStaticMethod<BroadcastReceiver.PendingResult>(
+ ShadowBroadcastPendingResult::class.java,
+ "create",
+ ClassParameter.from(Int::class.javaPrimitiveType!!, 0),
+ ClassParameter.from(String::class.java, ""),
+ ClassParameter.from(Bundle::class.java, Bundle()),
+ ClassParameter.from(Boolean::class.javaPrimitiveType!!, false),
+ )
+ ReflectionHelpers.callInstanceMethod<Any?>(
+ receiver,
+ "setPendingResult",
+ ClassParameter.from(BroadcastReceiver.PendingResult::class.java, pendingResult),
+ )
+
+ receiver.onReceive(application, Intent(Intent.ACTION_BOOT_COMPLETED))
+
+ return Shadow.extract(pendingResult)
+ }
+
+ private class DeferredMeshPrefs(private val loadDeviceAddress: suspend () -> String?) : MeshPrefs {
+ override val deviceAddress = MutableStateFlow<String?>(null)
+
+ override fun setDeviceAddress(address: String?) {
+ deviceAddress.value = address
+ }
+
+ override suspend fun awaitDeviceAddress(): String? = loadDeviceAddress()
+
+ override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> = MutableStateFlow(0)
+
+ override fun setStoreForwardLastRequest(address: String?, timestamp: Int) = Unit
+ }
+
+ private class StallingDispatcher : CoroutineDispatcher() {
+ private val queuedTasks = mutableListOf<Runnable>()
+ val queuedTaskCount: Int
+ get() = queuedTasks.size
+
+ override fun dispatch(context: CoroutineContext, block: Runnable) {
+ queuedTasks += block
+ }
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt
index b05ccdfcbb..75bcfabb07 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/BootCompleteReceiver.kt
@@ -20,8 +20,19 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.common.util.isValidDeviceAddress
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshPrefs
/** This receiver starts the MeshService on boot if a device was previously connected. */
@@ -30,7 +41,10 @@ class BootCompleteReceiver :
KoinComponent {
private val meshPrefs: MeshPrefs by inject()
+ private val dispatchers: CoroutineDispatchers by inject()
+ private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.default) }
+ @Suppress("TooGenericExceptionCaught")
override fun onReceive(context: Context, intent: Intent) {
// Only these two actions carry a background foreground-service-start exemption. The manifest also filters the
// OEM quick-boot actions, which do not, so acting on those would guarantee a rejected start.
@@ -38,17 +52,48 @@ class BootCompleteReceiver :
Logger.d { "BootCompleteReceiver: ignoring non-exempt action ${intent.action}" }
return
}
- val address = meshPrefs.deviceAddress.value
- if (address.isNullOrBlank() || address.equals("n", ignoreCase = true)) {
- Logger.d { "BootCompleteReceiver: no device previously connected, skipping service start" }
- return
- }
- Logger.i { "BootCompleteReceiver: starting MeshService after ${intent.action}" }
- MeshService.startService(context, ServiceStartTrigger.BootCompleted)
+ val pendingResult = goAsync()
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ try {
+ val address =
+ try {
+ withTimeout(PREFERENCES_LOAD_TIMEOUT_MILLIS) {
+ // Keep the IO read as a sibling so a queued dispatcher cannot delay timeout completion.
+ val preferencesLoad = scope.async(dispatchers.io) { meshPrefs.awaitDeviceAddress() }
+ try {
+ preferencesLoad.await()
+ } finally {
+ preferencesLoad.cancel()
+ }
+ }
+ } catch (_: TimeoutCancellationException) {
+ Logger.w { "BootCompleteReceiver: timed out loading the selected device" }
+ return@launch
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logger.w(e) { "BootCompleteReceiver: failed to load the selected device" }
+ return@launch
+ }
+
+ if (!isValidDeviceAddress(address)) {
+ Logger.d { "BootCompleteReceiver: no device previously connected, skipping service start" }
+ return@launch
+ }
+
+ Logger.i { "BootCompleteReceiver: starting MeshService after ${intent.action}" }
+ MeshService.startService(context, ServiceStartTrigger.BootCompleted)
+ } finally {
+ pendingResult.finish()
+ scope.cancel()
+ }
+ }
}
private companion object {
+ const val PREFERENCES_LOAD_TIMEOUT_MILLIS = 5_000L
+
/**
* `MY_PACKAGE_REPLACED` is filtered by the manifest so an in-place upgrade restores the radio link without
* waiting for the user to reopen the app, and it is exempt from the background-start restriction just as
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 2373c0bc7c..e58993d130 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -354,6 +354,8 @@ class FakeMeshPrefs : MeshPrefs {
deviceAddress.value = address
}
+ override suspend fun awaitDeviceAddress(): String? = deviceAddress.value
+
private val lastRequest = mutableMapOf<String?, MutableStateFlow<Int>>()
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> =
Served by rngit 1.4.2 - Generated in 0.13s